Skip to content

EVCacheValue: opt-in compact binary serialization with backwards-compatible reads#196

Merged
joegoogle123 merged 1 commit into
masterfrom
evcache-value-binary-serde
Jul 2, 2026
Merged

EVCacheValue: opt-in compact binary serialization with backwards-compatible reads#196
joegoogle123 merged 1 commit into
masterfrom
evcache-value-binary-serde

Conversation

@joegoogle123

@joegoogle123 joegoogle123 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Hashed-key values are wrapped in an EVCacheValue envelope (key, value, flags, ttl, createTime) that is currently serialized with Java ObjectOutputStream, adding ~50–80 bytes of structural overhead per item. This adds a compact, length-prefixed binary format for the envelope while remaining fully backwards-compatible on reads.

This PR is two commits intended to be squashed at merge:

  1. EVCacheValue: opt-in compact binary serde with backwards-compatible read — the wire format, transcoder dispatch, and the FP-gated rollout switch.
  2. Route the EVCacheValue binary-serialization toggle through EVCacheTranscoderProperties — extracts the FP resolution into a typed bundle with a per-app → global → static-default chain.

What changed

  • New EVCacheValueSerde class (com.netflix.evcache.pool) — public-final-non-instantiable codec, owns the wire format and all error handling:
    • static byte[] serialize(EVCacheValue) — length-prefixed binary layout: [magic 0x0C][reserved 0x00][int keyLen][key UTF-8][int valLen][value][int flags][long ttl][long createTime].
    • static EVCacheValue deserialize(byte[]) — bounds-checks length prefixes before allocating; on any corruption / unexpected exception warn-logs the failing field and a truncated hex dump (via Apache Commons Hex.encodeHexString, capped at 1024 bytes) and returns null. Matches BaseSerializingTranscoder's resilience contract (corruption → cache miss → caller refills from source of truth) so a single corrupt entry never crashes a get / getBulk / async pipeline.
    • static boolean isBinaryFormat(byte[]) — exposed for the dispatcher.
  • EVCacheTranscoder becomes a thin dispatcher (no try/catch):
    • serialize: gates on properties.isBinarySerializationEnabled() && o instanceof EVCacheValueEVCacheValueSerde.serialize; else super.serialize (Java).
    • deserialize: dispatches on EVCacheValueSerde.isBinaryFormatEVCacheValueSerde.deserialize; else super.deserialize.
  • EVCacheValue stays a pure POJO (codec moved out; constructor unchanged from pre-PR).
  • EVCacheTranscoderProperties (new bundle in com.netflix.evcache.config) — typed access to the FPs that govern EVCacheTranscoder. Today it holds one entry (USE_BINARY_SERIALIZATION); the Key enum is the extension point for future transcoder properties. Resolution chain per property: per-app override → global default → static default, read once at construction and cached. A getProperty(Key, Class, default) escape hatch supports future dynamic reads without forcing every caller off the cached primitive.
  • EVCacheImpl constructs an EVCacheTranscoderProperties for the envelope transcoder and injects it; max size is still read inline; compression stays pinned at Integer.MAX_VALUE so the leading magic byte is never gzip-rewritten.
  • Reads auto-detect format by the leading byte (0xAC 0xED = legacy Java, 0x0C = binary), so a new client decodes existing cache entries unchanged.

Format-flag decision (reuse SERIALIZED + magic byte, not a fresh flag)

The binary envelope keeps the existing SERIALIZED flag and is disambiguated from Java by the leading byte, rather than allocating a new CachedData flag. Rationale:

  • SERIALIZED semantically still means "serialized object → deserialize()"; the codec choice (Java vs binary) lives inside deserialize(). No flag constant is reassigned or repurposed, and decode() branch order is untouched.
  • Consumers that route on SERIALIZED (e.g. the admin inspector, cache-warmer) keep working without a new flag constant to propagate.
  • An old reader that hits binary bytes under SERIALIZED throws StreamCorruptedException (fails loud) rather than silently decoding garbage — which a fresh low-byte flag would cause (decodeString) on old readers.
  • Invariant (documented in EVCacheValueSerde Javadoc): SERIALIZED payloads are self-describing by leading byte; a future third format must use a distinct non-colliding magic + the reserved version byte.

Reserved version byte

Byte index 1 of the binary payload is reserved (always 0x00 today). Reader read-and-ignores; not validated. Reason: forward-compat without an emergency reader rollout. If today's readers rejected any non-zero version, introducing a v2 in the future would require shipping reader support fleet-wide before any writer could emit a v2 byte, and a single misconfigured writer would crash all readers. By accepting any value today, future readers can branch on this byte to introduce breaking format changes backwards-compatibly.

Feature Property (rollout gate)

Resolved through EVCacheTranscoderProperties with a three-level fallback:

  1. Per-app override: <appName>.binary.serialization.enabled
  2. Global default: default.evcache.binary.serialization.enabled
  3. Static default: false
  • Read once at client construction and cached as a primitive on the (immutable) transcoder ⇒ deploy/restart required to take effect; this is NOT a live runtime toggle. Flip the property, then redeploy the consuming app.
  • Default-off means production keeps writing Java; reads auto-detect both formats. Roll out reader-first: ship this change everywhere — including the admin inspector and cache-warmer — before enabling the FP for any writer.

Compatibility

  • A client with this change decodes existing Java-serialized values unchanged (dual-format read).
  • With the FP off (default), wire output is byte-identical to today.
  • Corrupt binary payloads degrade to cache miss (null), matching the existing Java path. A single corrupt entry never crashes the caller.

Testing

EVCacheValueSerdeTest — 17 cases via the public EVCacheTranscoder.encode/decode API:

  • Binary round-trip across edge cases (empty / unicode key / large 2 MB value / negative flags / zero ttl / negative & MAX createTime / MIN flags)
  • Transcoder wire shape (binary flag on): SERIALIZED flag set, leading byte 0x0C, reserved byte 0x00
  • Default transcoder (flag OFF) writes Java (0xAC 0xED) but reads both formats
  • Backwards-compat: legacy ObjectOutputStream-serialized envelope still decodes
  • Non-EVCacheValue passthrough (ArrayList stays on the Java path even with binary flag on)
  • Size win: testBinaryIsSmallerThanJava asserts the binary envelope is strictly smaller than the Java one for a representative item
  • Malformed binary: truncated, bogus oversize keyLen, negative keyLen all decode to null (logged with field + hex dump)
  • Forward-compat: testDecodeBinaryWithOptionalAdditiveFieldIsForwardCompat — appends an unknown trailing field to a typical payload and asserts the reader still returns the typical envelope (cross-consistency check: if a real new field is added everywhere, the test continues to pass; if someone adds a non-additive field, this test fails).

EVCacheTranscoderPropertiesTest — 5 cases for the resolution chain: per-app wins, global fallback when per-app unset, static default when both unset, per-app beats global, null appName routes to global.

./gradlew :evcache-core:test green. Also smoke-tested end-to-end against a locally-published snapshot in a sample Spring Boot Netflix app — round-trip put/get/delete on hashed keys passed.

Chunked-payload integration is not covered by an automated test in this PR — chunking lives in EVCacheClient.createChunks/assembleChunks, which are content-opaque (byte copy + CRC + manifest) and require a live client to exercise. The binary format introduces no new chunking risk by construction: assembleChunks reassembles bytes byte-for-byte and CRC-checks them against the manifest before handing the result to the transcoder.

🤖 Generated with Claude Code

@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch 15 times, most recently from d443e30 to 3892873 Compare June 4, 2026 22:22
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch 10 times, most recently from d46bf97 to 3444f24 Compare June 9, 2026 14:47
@joegoogle123 joegoogle123 requested a review from bihaoxwork June 9, 2026 14:48
Comment thread evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java Outdated
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch from 75d8249 to 40446ae Compare June 23, 2026 19:57
@joegoogle123 joegoogle123 changed the base branch from master to sync-getbulk-mixed-keys June 23, 2026 19:57
Comment thread evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java Outdated
Comment thread evcache-core/src/main/java/com/netflix/evcache/EVCacheImpl.java Outdated

@shy-1234 shy-1234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just give a second look to the compression threshold thing. Overall LGTM!

@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch from b7fdc7f to 40446ae Compare June 30, 2026 17:31
@joegoogle123 joegoogle123 changed the base branch from sync-getbulk-mixed-keys to master July 1, 2026 14:43
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch 4 times, most recently from 14c1cd2 to 59203c0 Compare July 2, 2026 15:28
…atible reads

Hashed-key values are wrapped in an EVCacheValue envelope
(key, value, flags, ttl, createTime) currently serialized with Java
ObjectOutputStream, adding ~50-80 bytes of structural overhead per item.
This adds a compact, length-prefixed binary format for the envelope while
remaining fully backwards-compatible on reads, plus a first-class typed
property bundle (EVCacheTranscoderProperties) that lives on the base
transcoder and drives the transcoder's runtime behavior.

## Wire format
[magic 0x0C][reserved 0x00][int keyLen][key UTF-8][int valLen][value]
[int flags][long ttl][long createTime]

- Leading magic byte disambiguates from Java's 0xAC 0xED.
- Reserved byte at index 1 is read-and-ignored today; future readers
  can branch on it for backwards-compatible format changes.
- Reads auto-detect format by leading byte: 0xAC 0xED -> Java, 0x0C -> binary.

## New classes
- EVCacheValueSerde (com.netflix.evcache.pool): the codec. Bounds-checks
  length prefixes before allocating; corrupt payloads warn-log the failing
  field + truncated hex dump (via Apache Commons Hex.encodeHexString, capped
  at 1024 bytes) and return null. Matches BaseSerializingTranscoder's
  resilience contract: corruption -> cache miss -> caller refills.

- EVCacheTranscoderProperties (com.netflix.evcache.config): typed access
  to the FastProperties that govern transcoder behavior. Three keys today
  (BINARY_SERIALIZATION_ENABLED, MAX_DATA_SIZE_BYTES, COMPRESSION_THRESHOLD_BYTES).
  The Key enum is the extension point for future keys. Per-app -> global ->
  static-default resolution chain; static value cached at construction for
  hot-path reads.

## EVCacheSerializingTranscoder integration
The bundle is a protected final field on the base class:
    protected final EVCacheTranscoderProperties properties;
Subclasses inherit one source of truth for FP resolution. Legacy
constructors are preserved and internally build a null-appName bundle.

## Feature Property rollout gate
Resolved through EVCacheTranscoderProperties with the three-level fallback:
- Per-app:  <appName>.binary.serialization.enabled
- Global:   default.evcache.binary.serialization.enabled
- Default:  false

Read once at client construction and cached as a primitive on the
(immutable) transcoder ==> deploy/restart required to take effect; NOT a
live runtime toggle. Flip the property, then redeploy the consuming app.

## Compatibility
- New clients decode existing Java-serialized values unchanged (dual-format read).
- With the FP off (default), wire output is byte-identical to today.
- Corrupt binary payloads degrade to cache miss (null), matching the Java path.
- Public EVCacheTranscoder constructors (0-arg, int, int int) preserved.

## Testing
- EVCacheValueSerdeTest — 17 cases via public transcoder API: binary
  round-trip edge cases, wire-shape assertions, backwards-compat with
  legacy Java bytes, non-EVCacheValue passthrough, size-win, malformed
  binary paths, forward-compat with optional additive fields.
- EVCacheTranscoderPropertiesTest — 15 cases (5 per key) covering the
  three-level resolution chain. Property keys are asserted as string
  literals so an enum rename fails the tests loudly.
- ./gradlew :evcache-core:test green.
- Smoke-tested end-to-end against a locally-published snapshot in a Spring
  Boot Netflix sample app; round-trip put/get/delete on hashed keys passes.

Chunked-payload integration remains covered by EVCacheClient's existing
byte-level assembleChunks + CRC path; the binary format introduces no new
chunking risk by construction.
@joegoogle123 joegoogle123 force-pushed the evcache-value-binary-serde branch from 59203c0 to c38f913 Compare July 2, 2026 18:47
@joegoogle123 joegoogle123 merged commit 7af5f9c into master Jul 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants